Domain Events vs Integration Events: The Line Your Team Keeps Crossing
Both are called "events." Both get published to a broker. That surface-level similarity is exactly why teams keep publishing one where they should be publishing the other, and why the mistake is so hard to notice until a schema change breaks three unrelated services.
Two Different Jobs Wearing the Same Costume
A domain event describes something meaningful that happened inside a bounded context, in the
vocabulary of that context's own model. OrderLineItemQuantityAdjusted. It's detailed, it's
internal, and it's allowed to change shape whenever the domain model inside that context changes, because
nothing outside that context should be listening to it.
An integration event describes a fact one bounded context is willing to publish as a stable contract to
everyone else. OrderConfirmed, with a small, deliberately limited payload. It's the public
API of your event stream — the thing other teams are allowed to build durable dependencies on, and the
thing you now owe backward compatibility to, the same way you'd owe it to any other public interface.
The trap is that nothing about the broker, the topic naming, or the code that calls
publish() forces you to notice which one you're building. They look identical in a Kafka UI.
The difference is entirely in intent, and intent doesn't show up in a schema registry unless you put it
there on purpose.
How the Line Actually Gets Crossed
It rarely happens in one deliberate decision. It happens gradually: a domain event, originally meant purely for an internal projection or audit log within the same context, gets subscribed to by a second team because it happened to contain a field they needed and asking for a proper integration event felt like unnecessary process. It works. Nobody objects. Six months later, three other teams have quietly subscribed to the same internal event because it was already there and had the data.
// Published for this context's own read-model projection.
eventBus.publish("order.lineItem.quantityAdjusted", {
orderId,
lineItemId,
previousQuantity,
newQuantity,
adjustedByUserId,
internalPricingSnapshot // <- never meant for outside eyes
});
Now the Order context can't refactor its internal model — say, changing how quantity adjustments are represented, or removing that pricing snapshot field entirely — without a cross-team migration, because a detail that was supposed to be a private implementation artifact quietly became load-bearing infrastructure for other teams. This is the exact failure mode bounded contexts exist to prevent, sneaking back in through the one channel — the event bus — that everyone assumed was already the "safe," decoupled way to share data.
The Fix Is a Translation Step, Not a Naming Convention
The correct shape is an explicit boundary: domain events flow freely within a context and can change as often as the model needs to. A dedicated component — often literally called an event publisher or integration adapter — listens to the ones that represent externally meaningful facts, and translates them into a smaller, deliberately stable integration event before it ever reaches a shared topic.
domainEventBus.subscribe("order.lineItem.quantityAdjusted", (event) => {
// Internal detail (pricing snapshot, who adjusted it) never leaves the context.
});
domainEventBus.subscribe("order.confirmed", (event) => {
integrationEventBus.publish("OrderConfirmed", {
orderId: event.orderId,
confirmedAt: event.timestamp,
totalAmount: event.total
});
});
This isn't bureaucracy for its own sake. It's the same reason a service doesn't expose its database schema directly as its API — the internal representation and the public contract are allowed to evolve at different speeds precisely because there's a translation layer absorbing the difference. Skip the layer, and every internal refactor becomes a distributed one.
A Question That Catches Most of the Mistakes
Before publishing any event to a shared broker, ask: is this describing something in the vocabulary of my own domain model, or something any other team should be able to build a permanent dependency on without knowing anything about my internals? If it's the former and another team wants it, that's a signal to build a proper integration event, not a green light to let them subscribe to what you already had lying around. The five extra minutes of defining a stable contract is cheaper than the migration you'll owe everyone subscribed to your internals the day your domain model needs to change.
FAQ
Do domain events and integration events need to go on separate topics or brokers?
They don't strictly need separate infrastructure, but separating them by topic or namespace makes the boundary visible and enforceable — for example, by restricting cross-team subscriptions to a clearly named "integration" or "public" set of topics only.
Isn't adding a translation layer just extra work for events that will never change?
Even events that seem stable today can change as the domain model evolves, and the cost of adding the translation layer upfront is small compared to the cost of migrating every subscriber later. It's cheap insurance, not speculative engineering.
How do I migrate an existing internal event that other teams have already subscribed to?
Introduce a proper integration event with a stable contract, have it published alongside the existing one, migrate subscribers over on their own schedule, and only remove the old event once nobody outside the context depends on it anymore.
Is this the same distinction as "public" vs "private" API endpoints?
Yes, conceptually — a domain event is like a private internal method, and an integration event is like a versioned public API endpoint. The same discipline around backward compatibility and deliberate publishing applies to both.
